const Board = require("./board");
const Timer = require("nanotimer");

const MICROSECONDS_PER_SECOND = 1000000;
const priv = new Map();
let defaultOctave = 4;

function clearTimer(target) {
  if (!target.timer) {
    return target;
  }

  target.timer.clearInterval();
  delete target.timer;

  return target;
}

const Controllers = {
  /**
   * Timer-based tone generator using digital high/low piezo.
   */
  DEFAULT: {
    initialize: {
      writable: true,
      value() {
        this.io.pinMode(this.pin, this.io.MODES.OUTPUT);
      },
    },
    tone: {
      writable: true,
      value(tone, duration) {
        if (isNaN(tone) || isNaN(duration)) {
          // Very Bad Things happen if one tries to play a NaN tone
          throw new Error(
            "Piezo.tone: invalid tone or duration"
          );
        }

        clearTimer(this);

        this.timer = new Timer();
        let value = 1;

        this.timer.setInterval(() => {
          value = value === 1 ? 0 : 1;
          this.io.digitalWrite(this.pin, value);

          if ((this.timer.difTime / 1000000) > duration) {
            clearTimer(this);
          }
        }, null, `${tone}u`, () => {});

        return this;
      },
    },
    noTone: {
      writable: true,
      value() {
        this.io.digitalWrite(this.pin, 0);
        return clearTimer(this);
      },
    },
  },

  I2C_BACKPACK: {
    ADDRESSES: {
      value: [0x0A]
    },
    REGISTER: {
      value: {
        NO_TONE: 0x00,
        TONE: 0x01,
      },
    },
    initialize: {
      writable: true,
      value(options) {
        const { Drivers } = require("./sip");
        const address = Drivers.addressResolver(this, options);
        const state = priv.get(this);

        this.io.i2cConfig(options);
        state.address = address;
      }
    },
    tone: {
      writable: true,
      value(tone, duration) {
        const state = priv.get(this);

        if (isNaN(tone) || isNaN(duration)) {
          throw new Error(
            "Piezo.tone: invalid tone or duration"
          );
        }

        this.io.i2cWrite(state.address, [
          this.REGISTER.TONE,
          this.pin,
          (tone >> 8) & 0xff,
          tone & 0xff,
          (duration >> 24) & 0xff,
          (duration >> 16) & 0xff,
          (duration >> 8) & 0xff,
          duration & 0xff,
        ]);

        return this;
      },
    },
    noTone: {
      writable: true,
      value() {
        const state = priv.get(this);

        this.io.i2cWrite(state.address, [
          this.REGISTER.NO_TONE,
          this.pin,
        ]);

        return this;
      },
    },
  },
};

class Piezo {
  constructor(options) {

    Board.Component.call(
      this, options = Board.Options(options)
    );

    Board.Controller.call(this, Controllers, options);

    // Piezo instance properties
    const state = {
      isPlaying: false,
      timeout: null,
      address: null,
    };

    priv.set(this, state);

    Object.defineProperties(this, {
      isPlaying: {
        get() {
          return state.isPlaying;
        }
      }
    });

    if (typeof this.initialize === "function") {
      this.initialize(options);
    }
  }

  /**
   * Play a note for a duration.
   * @param {string} note - see Piezo.Notes.  Case-insensitive.
   *   If a note name without an octave number is given (e.g. "C#" instead of
   *   "C#4") then the configured default octave will be used.
   *   @see Piezo.prototype.defaultOctave
   * @param {number} duration - in milliseconds.
   */
  note(note, duration) {
    return this.frequency(Piezo.Parsers.hzFromInput(note), duration);
  }

  /**
   * Play a tone for a duration.
   * This is a lower-level method than frequency (which does
   * the translation from frequency to tone for you). Most of
   * the time you likely want to use frequency.
   * @param {number} tone - Given as a computed duty-cycle,
   *   in microseconds. Larger values produce lower tones.
   *   See https://en.wikipedia.org/wiki/Duty_cycle
   * @param {number} duration - in milliseconds.
   */
  tone(tone, duration) {
    return this.frequency(Piezo.ToFrequency(tone), duration);
  }

  /**
   * Play a frequency for a duration.
   * @param {number} frequency - in Hz
   * @param {number} duration - in milliseconds
   */
  frequency(frequency, duration) {
    return this.tone(Piezo.ToTone(frequency), duration);
  }

  play(tune, callback) {
    if (typeof tune !== "object") {
      tune = {
        song: tune
      };
    }

    if (typeof tune.song === "string") {
      tune.song = Piezo.ToSong(tune.song, tune.beats);
    }

    if (tune.song && !Array.isArray(tune.song)) {
      /*
        If `tune.song` was present and not falsy,
        but also is not a string (above), or an array
        (presently), then it is likely a Hz value, so
        normalize song to the appropriate array format:
       */
      tune.song = [tune.song];
      /*
        Note: This path is taken for calls that look
        like this:

        piezo.play({
          song: 262,
        }, ...)

        Where 262 is a frequency in Hz
       */
    }

    const state = priv.get(this);
    const tempo = tune.tempo || 250;
    // Length for a single beat in ms
    const beatDuration = Math.round(60000 / tempo);
    const song = tune.song || [];
    let duration;
    let nextNoteIndex = 0;

    const next = () => {
      if (nextNoteIndex === song.length) {
        // No more notes in song:
        // Song is over
        state.isPlaying = false;
        if (typeof callback === "function") {
          callback(tune);
        }
        return;
      }

      const note = song[nextNoteIndex];
      const hz = Piezo.Parsers.hzFromInput(note);
      const beat = Piezo.Parsers.beatFromNote(note);

      duration = beat * beatDuration;
      nextNoteIndex++;

      if (hz === null) {
        this.noTone();
      } else {
        this.frequency(hz, duration);
      }

      state.timeout = setTimeout(next, duration);
    };

    // We are playing a song
    state.isPlaying = true;

    next();

    return this;
  }

  off() {
    return this.noTone();
  }

  stop() {
    const state = priv.get(this);

    /* istanbul ignore else */
    if (state.timeout) {
      clearTimeout(state.timeout);
      state.timeout = null;
    }

    return this;
  }
}

// These notes are rounded up at .5 otherwise down.
Piezo.Notes = {
  "c0": 16,
  "c#0": 17,
  "d0": 18,
  "d#0": 19,
  "e0": 21,
  "f0": 22,
  "f#0": 23,
  "g0": 25,
  "g#0": 26,
  "a0": 28,
  "a#0": 29,
  "b0": 31,
  "c1": 33,
  "c#1": 35,
  "d1": 37,
  "d#1": 39,
  "e1": 41,
  "f1": 44,
  "f#1": 47,
  "g1": 49,
  "g#1": 52,
  "a1": 55,
  "a#1": 58,
  "b1": 62,
  "c2": 65,
  "c#2": 69,
  "d2": 73,
  "d#2": 78,
  "e2": 82,
  "f2": 87,
  "f#2": 93,
  "g2": 98,
  "g#2": 104,
  "a2": 110,
  "a#2": 117,
  "b2": 124,
  "c3": 131,
  "c#3": 139,
  "d3": 147,
  "d#3": 156,
  "e3": 165,
  "f3": 175,
  "f#3": 185,
  "g3": 196,
  "g#3": 208,
  "a3": 220,
  "a#3": 233,
  "b3": 247,
  "c4": 262,
  "c#4": 277,
  "d4": 294,
  "d#4": 311,
  "e4": 330,
  "f4": 349,
  "f#4": 370,
  "g4": 392,
  "g#4": 415,
  "a4": 440,
  "a#4": 466,
  "b4": 494,
  "c5": 523,
  "c#5": 554,
  "d5": 587,
  "d#5": 622,
  "e5": 659,
  "f5": 698,
  "f#5": 740,
  "g5": 784,
  "g#5": 831,
  "a5": 880,
  "a#5": 932,
  "b5": 988,
  "c6": 1047,
  "c#6": 1109,
  "d6": 1175,
  "d#6": 1245,
  "e6": 1319,
  "f6": 1397,
  "f#6": 1480,
  "g6": 1568,
  "g#6": 1661,
  "a6": 1760,
  "a#6": 1865,
  "b6": 1976,
  "c7": 2093,
  "c#7": 2217,
  "d7": 2349,
  "d#7": 2489,
  "e7": 2637,
  "f7": 2794,
  "f#7": 2960,
  "g7": 3136,
  "g#7": 3322,
  "a7": 3520,
  "a#7": 3729,
  "b7": 3951,
  "c8": 4186,
  "c#8": 4435,
  "d8": 4699,
  "d#8": 4978,
  "e8": 5274,
  "f8": 5588,
  "f#8": 5920,
  "g8": 6272,
  "g#8": 6645,
  "a8": 7040,
  "a#8": 7459,
  "b8": 7902,
};

Piezo.Frequencies = Object.keys(Piezo.Notes).reduce((accum, note) => {
  accum[Piezo.Notes[note]] = note;
  return accum;
}, {});

Piezo.Parsers = {};
/**
 * Get the tone from the current note. note
 * could be an int, string, array or null.
 * If int or null, leave alone. Otherwise,
 * derive what the tone should be.
 * @return int | null
 */
Piezo.Parsers.hzFromInput = input => {
  let output = input;

  if (Array.isArray(input)) {
    output = input[0];
  }

  // Is it a valid frequency?
  if (typeof output === "number" &&
      Piezo.Frequencies[output]) {
    return output;
  }

  // See above: Piezo.Notes { ... }
  if (typeof output === "string") {
    output = output.toLowerCase().trim();

    // Example: c#, c
    if (output.endsWith("#") || output.length === 1) {
      output += defaultOctave;
    }

    // There will never be a 0 tone
    output = Piezo.Notes[output] || null;
  }

  // Normalize NaN, null & undefined to null
  if (isNaN(output)) {
    output = null;
  }

  return output;
};

/**
 * Obtain the beat/duration count from the current
 * note. This is either an int or undefined. Default
 * to 1.
 * @return int (default 1)
 */
Piezo.Parsers.beatFromNote = note => {
  let beat = 1;
  if (Array.isArray(note) && note[1] !== undefined) {
    // If extant, beat will be second element of note
    beat = note[1];
  }
  return beat;
};

/**
 * Validate the octave provided to ensure the value is
 * supported and won't crash the board.
 * @return bool
 */
Piezo.isValidOctave = octave => typeof octave === "number" && (octave >= 0 && octave <= 8);

/**
 * Set or get a default octave for all notes
 * @return number
 */
Piezo.defaultOctave = octave => {
  if (Piezo.isValidOctave(octave)) {
    defaultOctave = octave;
  }

  return defaultOctave;
};

Piezo.ToFrequency = tone => {
  const toneSeconds = tone / MICROSECONDS_PER_SECOND;
  const period = toneSeconds * 2;
  return Math.round(1 / period);
};

Piezo.ToTone = frequency => {
  const period = 1 / frequency;
  const duty = period / 2;
  return Math.round(duty * MICROSECONDS_PER_SECOND);
};

Piezo.ToSong = (stringSong, beats = 1) => {
  const notes = stringSong.split(" ");
  const song = [];
  let note;
  let lastNote;
  while (notes.length) {
    note = notes.shift();
    if (/^[0-9]+$/.test(note)) {
      note = parseInt(note, 10);
    }
    lastNote = song[song.length - 1];
    if (lastNote && lastNote[0] === note) {
      lastNote[1] += beats;
    } else {
      song.push([note, beats]);
    }
  }
  return song;
};


module.exports = Piezo;
